前言
本篇文章,我将带领你使用 Leafer 创建一个简单的拼图游戏。通过实现思路、步骤讲解以及代码演示,可以带你轻松上手使用 Leafer 编写游戏,同时可以让没有使用过 Leafer 的开发也能轻松理解并动手尝试。
最终效果图

思路
拼图游戏实现思路简单,功能也复杂,借助 Leafer 我们可以快速实现一个拼图游戏项目。Leafer 提供了高度封装的 Canvas 操作API,让我们能专注于游戏逻辑,而不需过多关注底层实现,整体开发体验非常良好。
拼图游戏的核心逻辑包括以下几步:
- 创建拼图容器:用于存储和显示所有的拼图块。
- 拆分图像:将一张完整的图片切割成若干小块,形成拼图。
- 标记并打乱图像块:为每一块拼图标记正确的位置,然后将它们打乱顺序。
- 拖拽与交互:允许玩家拖动拼图块,并在合适的地方释放。
- 检查排序:实时检查拼图块的当前位置是否符合其最初的位置,确定游戏是否完成。
实现
下面将详细讲解如何使用 Leafer 进行实现,只实现相关核心步骤,不会贴出完整代码,相关完整代码已经开源到 github,链接放在文章末尾,感兴趣自行 clone 查阅。
创建拼图容器
使用 Leafer 的 App 结构来初始化我们的游戏环境。这不仅便于我们管理游戏中的元素,也为后续可能的扩展提供了便利。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function createGameApp(view) { const app = new App({ view, fill: 'transparent', move: { disabled: true }, zoom: { disabled: true } }) app.tree = app.addLeafer(); return app; }
|
我们使用 Box 创建了一个 500x500 的拼图容器。使用 Box 的好处在于它可以自动处理边界检测,让后续的图片拖拽逻辑更加简洁,不在需要我们自己实现容器范围内的边界检测。。
1 2 3 4 5 6 7 8 9 10
| function createWrapper() { return new Box({ width: 500, height: 500, x: 0, y: 0, stroke: '#3aafff', fill: 'transparent', }); }
|
将容器添加到 app 之后,就完成了我们的容器创建部分。
1 2 3
| const app = createGameApp('game') const wrapper = createWrapper(); app?.tree.add(wrapper)
|
拆分图像
开发拼图游戏的核心功能在于将一张图片拆分成 n * n 张图片。在 Leafer 中,我们通过设置 Rect 的 fill 属性来实现图片的展示,这样我们可以方便的使用 clip 模式从原图中裁剪出所需区域。
首先,我们创建一个 500x500 的矩形,并将其 fill 属性设置为图片:
1 2 3 4 5 6 7 8 9 10
| new LeaferRect({ width: 500, height: 500, x: 0 , y: 0, fill: { type: 'image', url: url, }, });
|
接下来,我们可以通过设置 fill.mode 为 'clip' 和 fill.offset 来指定图片的裁剪区域。例如,如果我们要从一张 500x500 的图片中裁剪出 100x100 的区域,效果如下:

我们将 rect 填充模式设置为 clip 后,切割位置设置为 {x: 100, y: 100}
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| new LeaferRect({ width: 500, height: 500, x: 0 , y: 0, fill: { type: 'image', url: url, mode: 'clip', offset: {x: 100, y: 100} }, });
|
这样,我们就可以看到图片的红色部分被裁剪出来了,而灰色的背景实际效果是透明。

如果 offset 的坐标为负数,则图片会向左/向上平移,从而裁剪出不同的区域。
1
| offset: {x: -100, y: -100}
|
效果如下:

我们现在已经知道了图片平移的方式,在 clip 模式下,图片不会自适应宽高,为了控制裁剪后图片的大小,我们还可以设置 width 和 height 属性。例如,我们想要显示一个 100x100 的小方块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| new LeaferRect({ width: 100, height: 100, x: 0 , y: 0, fill: { type: 'image', url: url, mode: 'clip', offset: {x: 0, y: 0} }, });
|
对于拼图游戏来说,我们可以根据 offset 的坐标值,有规律地裁剪出 n * n 个小方块,然后将它们拼接起来,就能得到一个完整的图片。
例如,第一个方块的 offset 为 { x: 0, y: 0 },第二个为 { x: -100, y: 0 },第三个为 { x: -200, y: 0 },依此类推。当第一行的 5 个方块裁剪完成后,第二行的第一个方块的 offset 就可以设置为 { x: 0, y: -100 },以此类推,直到完成所有的方块。

现在我们只要根据上面得出的规律将 5x5 张图片进行裁剪即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const count = 5
const size = 100 for(let i = 0; i < Math.pow(count, 2); i++) { const x = (i % count) * size; const y = Math.floor(i / count) * size; const img = new Rect({ x, y, width: size, height: size, fill:{ type: 'image', url: '/puzzle/500x500.jpg', mode: 'clip', offset: {x: -x, y: -y} } }) wrapper.add(img) }
|
标记并打乱图像块
根据上面的算法,我们已经拆分成了 n * n 个图片,现在我们需要给每一块拼图标记正确的顺序,用于后续校验。
data 属性是 leafer 提供用户存储数据的,我们可以在里面存储自定义数据,通过设置 draggable 和 dragBounds 我们可以限制图片在 box 内拖拽。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const count = 5 const size = 100
let images = []; for(let i = 0; i < Math.pow(count, 2); i++) { const x = (i % count) * size; const y = Math.floor(i / count) * size; const img = new Rect({ x, y, width: size, height: size, fill:{ type: 'image', url: '/puzzle/500x500.jpg', mode: 'clip', offset: {x: -x, y: -y} }, data: {sortId: i}, draggable: true, dragBounds: 'parent', }) images.push(img) wrapper.add(img) }
|
我们直接通过 wrapper.children 打乱这些图块供玩家重新排序,因为 images 数组我们是按序存放的,所以乱序后,我们再遍历所有 wrapper.children 再从 images 中取出原图片位置进行替换,同时给图片设置一个 current 属性标记乱序后的位置,这样我们最后只需要检查所有图片的 sortId 是否等于 current 即可。
1 2 3 4 5 6 7 8 9 10 11
| function shuffleImages() { const imagePos = images.map(item => ({x: item.x, y: item.y})) wrapper.children.sort(() => Math.random() > Math.random() ? -1 : 1) wrapper.children.forEach((node, idx) => { node.set(imagePos[idx]) node.data!.current = idx; }) images = [...wrapper.children] }
|
拖拽与交互
监听每个图片拖拽事件,同时记录拖拽的节点和原始的 x, y, 再通过 DragEvent.setData 使 drop 时可以读取到数据。
1 2 3 4 5 6 7 8 9 10 11
| let dragNode = null; let [x, y] = [0, 0]; image.on(DragEvent.START, (evt) => { const node = evt.target; if (!node) return node.zIndex = 10000; x = node.x; y = node.y; dragNode = node; DragEvent.setData({x, y, dragNode}) })
|
监听 drop 事件,并进行移动行为的校验,只允许用户从上下左右四个方向相邻的图片进行交换,通过 evt.data 可以读取到通过 DragEvent.setData 设置进去的值。
因为我们在之前在节点内部记录了了 data.current 当前位置的值,所以当用户交换图片位置时同时将两个图片的 data.current 进行交换,交换之后,再进行 checkSort 检查是否完成拼图。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| image.on(DropEvent.DROP, (evt) => { const node = evt.target const {x, y, dragNode} = evt.data || {} if (!node || !dragNode) return if (node.x !== dragNode.x && node.y !== dragNode.y) return if (node.x >= dragNode.x + (dragNode.width * 2) || node.x < dragNode.x - dragNode.width) { return } if (node.y >= dragNode.y + (dragNode.height * 2) || node.y < dragNode.y - dragNode.height) { return } const targetIdx = node.data.current; const dragNodeIdx = dragNode.data.current; dragNode.data.current = targetIdx; node.data.current = dragNodeIdx;
dragNode.set({x: node.x, y: node.y}); node.set({x, y}); if (isCompleted()) { message.success('恭喜你,完成拼图') images.forEach((item) => { item.draggable = false item.off() }) } })
|
监听鼠标的 dragend 事件,用于恢复拖拽图片的位置。
1 2 3 4 5
| image.on(DragEvent.END, () => { if (!dragNode) return dragNode.set({zIndex: 1, x, y}) dragNode = null; })
|
检查排序
检查是否通过排序就很简单了,因为我们在节点内记录了正常顺序序号sortId, 以及当前位置的序号current, 只要遍历所有图片检查这两个值是否相等即可。
1 2 3 4 5 6
|
function isCompleted() { return this.images.every((item) => item.data!.current === item.data!.sortId); }
|
结语
通过以上步骤,我们梳理并实现了一个基本的拼图游戏。整个实现下来实际非常简单,主要难点在于思考如何对图片的切割,其次就是如何检查是否完成拼图。
相关链接